在 Day 11 中,我們為 Clean Architecture 的業務邏輯層建立了單元測試保護網。今天,我們要將測試視野提升到 UI 層面:如何將 UI 測試設計為架構的防護網。
在實際專案中,UI 測試是品質保證的重要投資,更是架構演進的安全網。今天,我們要從實務經驗的角度,探討如何設計一個既實用又可維護的 UI 測試策略。
UI 測試的價值在於保護架構邊界,確保各層之間的契約正確履行:
1. TestHelpers:測試資料工廠
// test/helpers/test_helpers.dart
static Activity createTestActivity({
String? id,
String? title,
ActivityCategory? category,
int? durationDays,
}) {
return Activity(
id: id ?? TestConstants.defaultActivityId,
title: title ?? TestConstants.defaultActivityTitle,
category: category ?? ActivityCategory.growth,
durationDays: durationDays ?? TestConstants.defaultDurationDays,
// ... 其他屬性
);
}
2. TestWidgetUtils:最小化測試環境
// test/helpers/widget_test_utils.dart
static Widget wrapWithApp(Widget child, {ThemeData? theme}) => MaterialApp(
theme: theme,
locale: const Locale('zh'),
localizationsDelegates: S.localizationsDelegates,
supportedLocales: S.supportedLocales,
home: Scaffold(body: child),
);
3. TestConstants:測試資料標準化
// test/helpers/test_constants.dart
class TestConstants {
static const String defaultActivityTitle = '測試活動';
static const int defaultDurationDays = 30;
static const int minTitleLength = 2;
static const int maxActivityDuration = 365;
static const String defaultActivityId = 'test-activity-1';
}
分層的 Key 設計提供不同層次的測試錨點:
// 頁面級 Key:驗證導航
key: const Key('index_screen')
// 功能級 Key:驗證關鍵功能
key: const Key('next_step_button')
// 動態 Key:驗證動態內容
key: Key('theme_${category.name}')
Key 策略原則:
1. 測試行為而非實作
// test/features/activity/presentation/widgets/activity_card_widget_test.dart
testWidgets('should display activity information', (tester) async {
final activity = TestHelpers.createTestActivity(
title: '測試活動標題',
durationDays: 30,
category: ActivityCategory.growth,
);
await tester.pumpWidget(ProviderScope(
child: TestWidgetUtils.wrapWithApp(
ActivityCardWidget(activity: activity),
),
));
expect(find.text('測試活動標題'), findsOneWidget);
expect(find.text('30天'), findsOneWidget);
expect(find.text('自我提升'), findsOneWidget);
});
2. 使用 Key 提高測試穩定性
testWidgets('should navigate to create activity page', (tester) async {
await tester.pumpWidget(ProviderScope(
child: TestWidgetUtils.wrapWithApp(HomeScreen()),
));
await tester.tap(find.byKey(const Key('create_activity_fab')));
await tester.pumpAndSettle();
// 驗證導航到建立活動頁面
expect(find.byType(CreateActivityScreen), findsOneWidget);
});
3. Provider Override 策略
testWidgets('should handle provider override correctly', (tester) async {
final mockService = MockLocalizationService();
when(mockService.current).thenReturn(MockS());
when(mockService.currentLocale).thenReturn(const Locale('zh'));
final container = ProviderContainer(
overrides: [
localizationServiceProvider.overrideWithValue(mockService),
],
);
final localization = container.read(currentLocalizationProvider);
final locale = container.read(currentLocaleProvider);
expect(localization, isA<S>());
expect(locale, equals('zh'));
container.dispose();
});
在 create_activity_flow_test.dart
中,我們遇到了 FloatingActionButton 的動畫時機問題。
穩健的解決方案:
testWidgets('should complete create activity flow', (tester) async {
await tester.pumpAndSettle(const Duration(seconds: 5));
// 多重檢測機制
if (find.byKey(const Key('create_activity_fab')).evaluate().isNotEmpty) {
await tester.tap(find.byKey(const Key('create_activity_fab')));
} else {
await tester.tap(find.byType(FloatingActionButton));
}
});
實務經驗法則:
測試覆蓋率的「質」比「量」更重要:
// 高品質測試:測試業務邏輯
testWidgets('should validate activity creation form', (tester) async {
await tester.pumpWidget(ProviderScope(
child: TestWidgetUtils.wrapWithApp(ActivityDetailsContentWidget()),
));
// 不填寫標題,直接點擊下一步
await tester.enterText(find.byKey(const Key('activity_title_field')), '');
await tester.tap(find.byKey(const Key('next_step_button')));
await tester.pumpAndSettle();
// 驗證顯示驗證錯誤訊息
expect(find.text('請輸入活動標題'), findsOneWidget);
});
建立測試文化的關鍵要素:
Integration Test 驗證多個組件協同工作的完整使用者流程。
使用者故事導向:
testWidgets('User can create and view activity end-to-end', (tester) async {
// 1. 使用者開啟 App
// 2. 點擊建立活動
// 3. 填寫活動資訊
// 4. 送出表單
// 5. 在清單中看到新活動
});
模擬真實依賴:
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Activity Creation Integration Tests', () {
setUpAll(() {
// 初始化測試環境
});
});
}
完整的活動建立流程測試
testWidgets('should complete create activity flow', (tester) async {
app.main();
await tester.pumpAndSettle();
// 點擊建立活動按鈕
await tester.tap(find.byKey(const Key('create_activity_fab')));
await tester.pumpAndSettle();
// 選擇主題
await tester.tap(find.byKey(const Key('theme_growth')));
await tester.tap(find.byKey(const Key('next_step_button')));
await tester.pumpAndSettle();
// 填寫活動資料
await tester.enterText(
find.byKey(const Key('activity_title_field')),
'30天成長挑戰',
);
await tester.tap(find.byKey(const Key('next_step_button')));
await tester.pumpAndSettle();
// 確認建立
await tester.tap(find.byKey(const Key('confirm_create_button')));
await tester.pumpAndSettle();
// 驗證結果
expect(find.text('30天成長挑戰'), findsOneWidget);
});
# 執行所有 Widget 測試
flutter test test/widgets/
# 執行 Integration 測試
flutter test integration_test/
# 產生測試覆蓋率報告
flutter test --coverage
透過今天的實戰探索,我們學到了以下核心觀念:
✅ 穩定性優先於抽象
✅ Key 是串連開發與測試的橋樑
find.byKey()
比 find.byType()
更穩定可靠✅ 測試品質比數量更重要
✅ 實務導向的解決方案
明天,我們將探討 Day 13: Firebase 專案設定與環境配置,學習如何建立 Firebase 專案、整合 FlutterFire CLI、管理多環境配置,以及設定安全規則,為雲端服務的整合奠定基礎。
期待與您在 Day 13 相見!